@starci/hfs 4.5.1 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/ci/cli.mjs +8 -4
  3. package/ci/local.mjs +146 -0
  4. package/ci/vars.mjs +22 -5
  5. package/ci/verify.mjs +16 -2
  6. package/lint/run.mjs +3 -4
  7. package/package.json +1 -1
  8. package/release/candidate-ci.mjs +111 -0
  9. package/release/gate.mjs +4 -5
  10. package/release/push.mjs +10 -1
  11. package/runtime/knowledge/hfs/canon-pins.yaml +9 -4
  12. package/runtime/knowledge/hfs/ci-standard.yaml +103 -0
  13. package/runtime/knowledge/hfs/rules.yaml +19 -2
  14. package/runtime/knowledge/sonar-gate.yaml +2 -2
  15. package/runtime/modules/kernel/failure-codes.yaml +10 -0
  16. package/runtime/modules/schemas/application-stacks.schema.yaml +56 -0
  17. package/runtime/packages/cli/src/catalog.generated.mjs +121 -36
  18. package/runtime/scripts/hfs/ci-declaration.mjs +10 -1
  19. package/runtime/scripts/hfs/lcov-merge.mjs +89 -0
  20. package/runtime/scripts/hfs/rules/a15-findings.mjs +3 -0
  21. package/runtime/scripts/hfs/rules/ci-standard.mjs +202 -0
  22. package/runtime/scripts/hfs/rules/coverage-below.mjs +1 -1
  23. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +14 -5
  24. package/runtime/scripts/hfs/rules/secrets.mjs +8 -10
  25. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
  26. package/runtime/scripts/lib/secret-patterns.mjs +77 -0
  27. package/scaffold/app.mjs +5 -4
  28. package/src/main.mjs +4 -2
  29. package/sync/ci-commands.mjs +81 -27
  30. package/sync/hygiene.mjs +4 -1
  31. package/sync/index.mjs +14 -11
  32. package/sync/quality-services.mjs +7 -5
  33. package/sync/readme-badges.mjs +18 -2
  34. package/sync/stack-images.mjs +5 -0
  35. package/templates/app/ci-workflows/build-cache-save.yml +8 -0
  36. package/templates/app/ci-workflows/github/workflows/ci.yml +52 -12
  37. package/templates/app/ci-workflows/install-steps.yml +3 -2
  38. package/templates/app/ci-workflows/jest-cache-save.yml +6 -0
  39. package/templates/app/ci-workflows/jest-cache.yml +4 -4
  40. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +53 -14
  41. package/templates/app/hooks/husky/pre-push +4 -2
  42. package/templates/app/skeleton/.github/dependabot.yml +1 -2
  43. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -2
  44. package/release/ci-red.mjs +0 -70
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.0 - 2026-10-18
4
+ - Added: `app release push --execute` pushes the release sha to `release/candidate-<version>` and moves the branch and the tag only when its ci.yml run is green (RELEASE_CANDIDATE_CI_RED / _TIMEOUT, RELEASE_CI_UNKNOWN; the owner's sha-bound override record is the emergency path); the managed ci.yml and pre-push hook carry the candidate ref; R301 CI_STANDARD in the app check.
5
+ - Added: the managed CI shards the unit run (`ci.jobs.test.shards`, default 6), lints each side in its own job and merges coverage in a coverage job (`starci app coverage merge`); `ci.jobs.<job>.timeoutMinutes`; jest/build caches keyed by the lockfile and saved always; `starci app ci local` runs the rendered jobs locally in parallel.
6
+ - Fixed: a scaffolded app root overrides `@graphql-tools/utils` to the canon pin 12.1.0, closing GHSA-7mx3-vvmw-hjmv (npm audit high) under @nestjs/graphql 13.x.
7
+ - Changed: `app release gate` no longer reads the default-branch CI (release/ci-red.mjs removed); the app trigger check allows the candidate branches.
8
+
9
+ ## 4.6.0 - 2026-10-18
10
+ - Added: `services.codecov|sonar.badgeToken` README badge tokens and `ci.github.visibility` with a private-repository notice (sync, ci verify); `ci.vars` `ownerValue: true` (var sync skips, var set reads stdin, verify checks the name).
11
+ - Fixed: the app release gate counts tag-push CI runs reachable from origin/main, main or HEAD; the secret scans exempt only declared badge tokens.
12
+ - Fixed: empty placeholder lines vanish so every managed file is prettier-stable; CI coverage proof heap, per-side Sonar lint reports merged for the import, per-fe-app build env; `app lint --side` combines with `--sonar`.
13
+
3
14
  ## 4.5.1 - 2026-10-18
4
15
  - Fixed: managed CI base refs, public build values, caching and timeouts; optional quality service handling, README formatting and Supabase migration-history parsing.
5
16
 
package/ci/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { readCiDeclaration, sealedKeyNames } from '../runtime/scripts/hfs/ci-dec
5
5
  import { github, refuse } from './github.mjs';
6
6
  import { setSecret } from './secret.mjs';
7
7
  import { setVariable, syncVariables } from './vars.mjs';
8
- import { verifyCi } from './verify.mjs';
8
+ import { badgeVisibilityNotices, verifyCi } from './verify.mjs';
9
9
 
10
10
  const COMMANDS = {
11
11
  'secret set': ['env', 'dependabot', 'repo'],
@@ -44,18 +44,22 @@ function parse(argv) {
44
44
 
45
45
  /** Manifest and sealed key reads remain offline; only github.mjs may invoke the GitHub CLI. */
46
46
  export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {} } = {}) {
47
+ // `ci local` runs the rendered CI on this machine (local.mjs): no manifest custody and no GitHub.
48
+ if (argv[0] === 'local') return (await import('./local.mjs')).ciLocalMain(argv.slice(1), { cwd, stdout, stderr, env });
47
49
  try {
48
50
  const parsed = parse(argv);
49
- let declaration, sealed;
51
+ let doc, declaration, sealed;
50
52
  try {
51
- declaration = readCiDeclaration(parseYaml(fs.readFileSync(path.join(cwd, '.starcistacks/application-stacks.yaml'), 'utf8')));
53
+ doc = parseYaml(fs.readFileSync(path.join(cwd, '.starcistacks/application-stacks.yaml'), 'utf8'));
54
+ declaration = readCiDeclaration(doc);
52
55
  sealed = sealedKeyNames(cwd, { fs });
53
56
  } catch { throw refuse('CI_DECLARATION_INVALID', 'the manifest or sealed key names could not be read'); }
54
57
  if (declaration.problems.length) throw refuse('CI_DECLARATION_INVALID', 'correct the CI declaration before using GitHub');
55
58
  const context = { ...parsed, declaration, sealed, gh: github({ cwd, env, spawn: tools.spawn }), stdin, stdout };
56
59
  if (parsed.command === 'secret set') return await setSecret(context);
57
- if (parsed.command === 'var set') return setVariable(context);
60
+ if (parsed.command === 'var set') return await setVariable(context);
58
61
  if (parsed.command === 'var sync') return syncVariables(context);
62
+ context.notices = badgeVisibilityNotices({ doc, gh: context.gh, repo: context.gh.repository(parsed.options.repo) });
59
63
  return verifyCi(context);
60
64
  } catch (error) {
61
65
  stderr(`${error.code ? error.message : 'CI_USAGE: CI command could not complete'}\n`);
package/ci/local.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // local.mjs - `starci app ci local [--jobs <id,...>] [--parallel <n>] [--dry-run] [--json]`: predict the CI result while GitHub runs it. It reads the app's
2
+ // managed .github/workflows/ci.yml (the CI standard's jobs: fast, lint per side, test per shard, coverage, build) and runs the `run:` steps of every job
3
+ // instance on this machine, matrix values substituted, independent instances in parallel and a job after the jobs it `needs`:
4
+ // - steps GitHub alone can run are skipped and listed: `uses:` actions (checkout, caches, artifacts, Codecov, Sonar), the install (`npm ci`: the local
5
+ // install is used), the lint baseline (STARCI_BASE_REF is set to origin/<default branch>), and every step whose `if:` or text needs GitHub context
6
+ // (a Sonar token, a tag ref, step outputs);
7
+ // - the unit shards write their lcov to be/coverage/shards/shard-<i> (on GitHub each shard has its own runner and uploads it), which is where the
8
+ // coverage job's `starci app coverage merge` reads them;
9
+ // - sonar and db-types run only when --jobs names them.
10
+ // Each instance logs to <tmp>/starci-ci-local-*/<job>[-<matrix>].log. Exit 0 every instance green, 1 one red, 2 bad usage or no ci.yml. Run steps through bash.
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { spawn } from 'node:child_process';
15
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
16
+
17
+ const DEFAULT_JOBS = ['fast', 'lint', 'test', 'coverage', 'build'];
18
+ const GITHUB_ONLY_IF = /SONAR_TOKEN\s*!=\s*''|refs\/tags|startsWith\(github\.ref|steps\.|hashFiles|github\.event/;
19
+ const LOCAL_IF = /^(?:\$\{\{\s*)?(?:always\(\)|!cancelled\(\)|env\.SONAR_TOKEN\s*==\s*'')(?:\s*\}\})?$/;
20
+
21
+ function parseArgs(argv) {
22
+ const opts = { jobs: null, parallel: Math.max(1, Math.min(4, Math.floor(os.cpus().length / 2))), dryRun: false, json: false };
23
+ for (let index = 0; index < argv.length; index += 1) {
24
+ const arg = argv[index];
25
+ if (arg === '--dry-run') opts.dryRun = true;
26
+ else if (arg === '--json') opts.json = true;
27
+ else if (arg === '--jobs' || arg === '--parallel') {
28
+ const value = argv[index + 1];
29
+ if (!value || value.startsWith('--')) throw new Error(`${arg} needs a value`);
30
+ if (arg === '--jobs') opts.jobs = value.split(',').map((job) => job.trim()).filter(Boolean);
31
+ else if (!/^[1-9]\d*$/.test(value)) throw new Error('--parallel takes a positive integer');
32
+ else opts.parallel = Number(value);
33
+ index += 1;
34
+ } else throw new Error(`unknown argument ${arg}`);
35
+ }
36
+ return opts;
37
+ }
38
+
39
+ /** Every combination of a one-level matrix ({side: [be, fe]} -> [{side: be}, {side: fe}]); [{}] without one. */
40
+ function combinations(matrix) {
41
+ const keys = Object.keys(matrix ?? {}).filter((key) => Array.isArray(matrix[key]));
42
+ return keys.reduce((all, key) => all.flatMap((partial) => matrix[key].map((value) => ({ ...partial, [key]: value }))), [{}]);
43
+ }
44
+
45
+ const substitute = (text, values) => String(text).replace(/\$\{\{\s*matrix\.([A-Za-z0-9_-]+)\s*\}\}/g, (whole, key) => (Object.hasOwn(values, key) ? String(values[key]) : whole));
46
+
47
+ /** The local verdict of one step: {run: command} or {skip: why}. */
48
+ function stepPlan(step, values) {
49
+ const name = step.name ?? (step.uses ? String(step.uses).split('@')[0] : String(step.run ?? '').split('\n')[0].slice(0, 60));
50
+ if (step.uses) return { name, skip: 'a GitHub action' };
51
+ const condition = step.if === undefined ? null : String(step.if).trim();
52
+ if (condition !== null && (GITHUB_ONLY_IF.test(condition) || !LOCAL_IF.test(condition))) return { name, skip: `if: ${condition} needs GitHub context` };
53
+ let command = substitute(step.run ?? '', values);
54
+ if (/^npm ci\b/.test(command.trim())) return { name, skip: 'the install: the local node_modules is used' };
55
+ if (command.includes('$GITHUB_ENV') || command.includes('GITHUB_OUTPUT')) return { name, skip: 'writes GitHub step state' };
56
+ if (/\$\{\{/.test(command)) return { name, skip: 'needs a GitHub expression' };
57
+ if (name === 'unit' && values.shard !== undefined) command = `${command} --coverageDirectory coverage/shards/shard-${values.shard}`;
58
+ const env = Object.fromEntries(Object.entries(step.env ?? {}).filter(([, value]) => !String(value).includes('${{')).map(([key, value]) => [key, substitute(value, values)]));
59
+ return { name, run: command, env, cwd: step['working-directory'] ? substitute(step['working-directory'], values) : '.' };
60
+ }
61
+
62
+ /** The plan of the workflow: [{id, label, needs, steps: [{name, run|skip}]}], one entry per job instance. */
63
+ function localPlan(workflow, jobs = null) {
64
+ const wanted = jobs ?? DEFAULT_JOBS;
65
+ const plan = [];
66
+ for (const [id, job] of Object.entries(workflow?.jobs ?? {})) {
67
+ if (!wanted.includes(id)) continue;
68
+ const needs = [job.needs ?? []].flat().map(String).filter((need) => wanted.includes(need));
69
+ for (const values of combinations(job.strategy?.matrix)) {
70
+ const suffix = Object.values(values).join('-');
71
+ plan.push({ id, label: suffix ? `${id}-${suffix}` : id, needs, steps: (job.steps ?? []).map((step) => stepPlan(step, values)) });
72
+ }
73
+ }
74
+ return plan;
75
+ }
76
+
77
+ function runStep(step, { root, env, log }) {
78
+ return new Promise((resolve) => {
79
+ fs.appendFileSync(log, `\n$ ${step.run}\n`);
80
+ const child = spawn('bash', ['-c', step.run], { cwd: path.resolve(root, step.cwd), env: { ...env, ...step.env }, windowsHide: true });
81
+ child.stdout.on('data', (chunk) => fs.appendFileSync(log, chunk));
82
+ child.stderr.on('data', (chunk) => fs.appendFileSync(log, chunk));
83
+ child.on('error', (error) => { fs.appendFileSync(log, `\n${error.message}\n`); resolve(1); });
84
+ child.on('close', (code) => resolve(code ?? 1));
85
+ });
86
+ }
87
+
88
+ async function runInstance(instance, { root, env, logs }) {
89
+ const log = path.join(logs, `${instance.label}.log`);
90
+ fs.writeFileSync(log, `starci app ci local: ${instance.label}\n`);
91
+ const started = Date.now();
92
+ for (const step of instance.steps.filter((entry) => entry.run !== undefined)) {
93
+ const code = await runStep(step, { root, env, log });
94
+ if (code !== 0) return { label: instance.label, ok: false, failed: step.name, seconds: Math.round((Date.now() - started) / 1000), log };
95
+ }
96
+ return { label: instance.label, ok: true, seconds: Math.round((Date.now() - started) / 1000), log };
97
+ }
98
+
99
+ /** Run the plan: instances whose needs are all done, `parallel` at a time; an instance whose need failed is not run. */
100
+ async function runPlan(plan, { parallel, root, env, logs, report }) {
101
+ const results = new Map();
102
+ const pending = [...plan];
103
+ const running = new Set();
104
+ const doneJob = (id) => plan.filter((entry) => entry.id === id).every((entry) => results.has(entry.label));
105
+ const jobOk = (id) => plan.filter((entry) => entry.id === id).every((entry) => results.get(entry.label)?.ok);
106
+ await new Promise((resolve) => {
107
+ const pump = () => {
108
+ for (const instance of [...pending]) {
109
+ if (running.size >= parallel) break;
110
+ if (!instance.needs.every(doneJob)) continue;
111
+ pending.splice(pending.indexOf(instance), 1);
112
+ if (!instance.needs.every(jobOk)) { results.set(instance.label, { label: instance.label, ok: false, failed: `needs ${instance.needs.join(', ')}`, seconds: 0 }); continue; }
113
+ const task = runInstance(instance, { root, env, logs }).then((result) => { results.set(instance.label, result); report(result); running.delete(task); pump(); });
114
+ running.add(task);
115
+ }
116
+ if (!pending.length && !running.size) resolve();
117
+ else if (!running.size && pending.length) { for (const instance of pending.splice(0)) results.set(instance.label, { label: instance.label, ok: false, failed: 'its needs never completed', seconds: 0 }); resolve(); }
118
+ };
119
+ pump();
120
+ });
121
+ return plan.map((instance) => results.get(instance.label));
122
+ }
123
+
124
+ /** `starci app ci local`. */
125
+ export async function ciLocalMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), env = process.env } = {}) {
126
+ let opts;
127
+ try { opts = parseArgs(argv); } catch (error) { stderr(`starci app ci local: ${error.message}; usage: starci app ci local [--jobs <id,...>] [--parallel <n>] [--dry-run] [--json]\n`); return 2; }
128
+ const file = path.join(cwd, '.github', 'workflows', 'ci.yml');
129
+ if (!fs.existsSync(file)) { stderr('starci app ci local: no .github/workflows/ci.yml (run starci app sync --write)\n'); return 2; }
130
+ const plan = localPlan(parseYaml(fs.readFileSync(file, 'utf8')), opts.jobs);
131
+ if (!plan.length) { stderr('starci app ci local: no job of the workflow is selected\n'); return 2; }
132
+ if (opts.dryRun) {
133
+ if (opts.json) stdout(`${JSON.stringify(plan)}\n`);
134
+ else for (const instance of plan) stdout(`${instance.label}${instance.needs.length ? ` (after ${instance.needs.join(', ')})` : ''}\n${instance.steps.map((step) => ` ${step.run !== undefined ? 'run ' : 'skip'} ${step.name}${step.skip ? ` - ${step.skip}` : ''}`).join('\n')}\n`);
135
+ return 0;
136
+ }
137
+ const logs = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-ci-local-'));
138
+ const defaultBranch = 'main';
139
+ const runEnv = { ...env, CI: 'true', STARCI_BASE_REF: env.STARCI_BASE_REF ?? `origin/${defaultBranch}`, SONAR_TOKEN: '' };
140
+ stdout(`starci app ci local: ${plan.length} job instance(s), ${opts.parallel} at a time; logs in ${logs}\n`);
141
+ const results = await runPlan(plan, { parallel: opts.parallel, root: cwd, env: runEnv, logs, report: (result) => { if (!opts.json) stdout(`${result.ok ? 'green' : 'RED '} ${result.label} (${result.seconds}s)${result.ok ? '' : ` at ${result.failed}: ${result.log}`}\n`); } });
142
+ const red = results.filter((result) => !result.ok);
143
+ if (opts.json) stdout(`${JSON.stringify({ ok: red.length === 0, results, logs })}\n`);
144
+ else stdout(red.length ? `starci app ci local: ${red.length} of ${results.length} red: ${red.map((result) => result.label).join(', ')}\n` : `starci app ci local: all ${results.length} green\n`);
145
+ return red.length ? 1 : 0;
146
+ }
package/ci/vars.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import { refuse, scopeArgs, scopeName } from './github.mjs';
2
3
  import { declaredEntry } from './secret.mjs';
3
4
 
@@ -15,18 +16,34 @@ function writeVariable(gh, repo, entry, value, options, stdout) {
15
16
  stdout(`set variable ${entry.name} (${scopeName(options)})\n`);
16
17
  }
17
18
 
18
- export function setVariable({ declaration, gh, options, name, stdout }) {
19
- const entry = declaredEntry(declaration.vars, name, options), value = valueOf(entry, options.value);
19
+ /** Owner-supplied input is read once from stdin, with one trailing newline removed and no value echoed. */
20
+ async function ownerValueOf(options, stdin) {
21
+ if (Object.hasOwn(options, 'value')) throw refuse('CI_VALUE_ARGV', 'an owner-supplied variable takes its value on stdin');
22
+ let value;
23
+ if (stdin === undefined) {
24
+ if (process.stdin.isTTY) throw refuse('CI_VALUE_EMPTY', 'an owner-supplied variable value is required on stdin');
25
+ value = fs.readFileSync(0, 'utf8');
26
+ } else value = typeof stdin === 'function' ? await stdin() : stdin;
27
+ value = String(value ?? '').replace(/\r?\n$/, '');
28
+ if (!value.length) throw refuse('CI_VALUE_EMPTY', 'an owner-supplied variable value is required on stdin');
29
+ return value;
30
+ }
31
+
32
+ /** Owner-supplied variables accept stdin only; other public variables retain manifest and argv agreement. */
33
+ export async function setVariable({ declaration, gh, options, name, stdin, stdout }) {
34
+ const entry = declaredEntry(declaration.vars, name, options);
35
+ const value = entry.ownerValue ? await ownerValueOf(options, stdin) : valueOf(entry, options.value);
20
36
  writeVariable(gh, gh.repository(options.repo), entry, value, options, stdout);
21
37
  return 0;
22
38
  }
23
39
 
24
40
  /** Sync validates the whole plan first and performs no GitHub access until execution is requested. */
25
41
  export function syncVariables({ declaration, gh, options, stdout }) {
26
- const plan = declaration.vars.map((entry) => ({ entry, value: valueOf(entry), options: { env: entry.scope === 'environment' ? entry.environment : undefined } }));
27
- const repo = options.execute && plan.length ? gh.repository(options.repo) : undefined;
42
+ const plan = declaration.vars.map((entry) => ({ entry, value: entry.ownerValue ? undefined : valueOf(entry), options: { env: entry.scope === 'environment' ? entry.environment : undefined } }));
43
+ const repo = options.execute && plan.some((item) => !item.entry.ownerValue) ? gh.repository(options.repo) : undefined;
28
44
  for (const item of plan) {
29
- if (options.execute) writeVariable(gh, repo, item.entry, item.value, item.options, stdout);
45
+ if (item.entry.ownerValue) stdout(`skip variable ${item.entry.name} (${scopeName(item.options)}): owner-supplied value; the owner runs starci app ci var set ${item.entry.name} [--env ENV] with the value on stdin\n`);
46
+ else if (options.execute) writeVariable(gh, repo, item.entry, item.value, item.options, stdout);
30
47
  else stdout(`would set variable ${item.entry.name} (${scopeName(item.options)})\n`);
31
48
  }
32
49
  return 0;
package/ci/verify.mjs CHANGED
@@ -1,7 +1,8 @@
1
+ import { badgeTokenNotices } from '../sync/readme-badges.mjs';
1
2
  import { refuse, scopeArgs, scopeName } from './github.mjs';
2
3
 
3
4
  /** Verification reports names and scopes only; values are compared in memory and excluded from findings. */
4
- export function verifyCi({ declaration, sealed, gh, options, stdout }) {
5
+ export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [] }) {
5
6
  const repo = gh.repository(options.repo), stores = [];
6
7
  const environments = [...new Set([...declaration.secrets, ...declaration.vars].filter((item) => item.scope === 'environment').map((item) => item.environment))].sort();
7
8
  for (const scope of [{}, ...environments.map((env) => ({ env })), { dependabot: true }]) {
@@ -29,8 +30,21 @@ export function verifyCi({ declaration, sealed, gh, options, stdout }) {
29
30
  add(sameKind.some((entry) => entry.name === row.name) ? 'CI_SCOPE_MISMATCH' : store.kind === 'secret' ? 'CI_SECRET_UNDECLARED' : 'CI_VAR_UNDECLARED', row.name, store.scope);
30
31
  }
31
32
  }
32
- if (options.json) stdout(`${JSON.stringify({ ok: findings.length === 0, findings }, null, 2)}\n`);
33
+ if (options.json) stdout(`${JSON.stringify({ ok: findings.length === 0, findings, notices }, null, 2)}\n`);
33
34
  else if (!findings.length) stdout('CI verification passed\n');
34
35
  else for (const finding of findings) stdout(`${finding.code}: ${finding.name} (${finding.scope})\n`);
36
+ if (!options.json) for (const notice of notices) stdout(`${notice}\n`);
35
37
  return findings.length ? 1 : 0;
36
38
  }
39
+
40
+ /** Manifest visibility wins; optional GitHub visibility discovery never changes verification findings or errors. */
41
+ export function badgeVisibilityNotices({ doc, gh, repo }) {
42
+ let visibility = doc?.ci?.github?.visibility;
43
+ if (visibility === undefined) {
44
+ try {
45
+ const remote = gh.json(['repo', 'view', '--json', 'visibility', '--repo', repo]);
46
+ visibility = ['PRIVATE', 'INTERNAL'].includes(remote?.visibility) ? 'private' : 'public';
47
+ } catch { return []; }
48
+ }
49
+ return badgeTokenNotices({ ...doc, ci: { ...doc?.ci, github: { ...doc?.ci?.github, visibility } } }, { repository: `https://github.com/${repo}` });
50
+ }
package/lint/run.mjs CHANGED
@@ -26,7 +26,7 @@ import { STYLE_GLOB } from '../sync/index.mjs';
26
26
  import { byCodeUnit } from '../report/order.mjs';
27
27
  import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
28
28
 
29
- // --side judges only that side; root HFS findings belong to be, and Sonar always requires the full lint.
29
+ // --side judges only that side; root HFS findings belong to be, its Sonar report stays on that side.
30
30
  export const LINT_SCHEMA = 'starci/lint@1';
31
31
  /** The side whose stylesheets stylelint judges. */
32
32
  const STYLE_SIDE = 'fe';
@@ -42,11 +42,10 @@ export function workspaceOf(workspace) {
42
42
  return rel;
43
43
  }
44
44
 
45
- /** Side scopes partition the full lint; Sonar requires the complete report and a frontend workspace cannot belong to be. */
45
+ /** Side scopes partition the full lint; Sonar reports share that scope and a frontend workspace cannot belong to be. */
46
46
  function validateSide(opts) {
47
47
  if (opts.side === undefined) return;
48
48
  if (!SIDES.includes(opts.side)) throw new Error('LINT_SIDE_INVALID: --side is be or fe');
49
- if (opts.sonar !== undefined) throw new Error('LINT_SIDE_SONAR_CONFLICT: --side and --sonar do not combine');
50
49
  if (opts.side === 'be' && opts.workspace !== undefined) throw new Error('LINT_SIDE_WORKSPACE_CONFLICT: --side be cannot judge a frontend workspace');
51
50
  }
52
51
 
@@ -312,7 +311,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
312
311
 
313
312
  let properties = '';
314
313
  try { properties = fs.readFileSync(path.join(repoRoot, 'sonar-project.properties'), 'utf8'); } catch { /* no properties: every finding keeps its own path */ }
315
- const sourceRoots = sourceRootsOf(properties);
314
+ const sourceRoots = sourceRootsOf(properties).filter((root) => findingOnSide({ path: root }, opts.side));
316
315
  const tracked = sourceRoots.length ? trackedFiles(repoRoot) : [];
317
316
  const sonar = mergeReports([
318
317
  sonarReport(kept, { sourceRoots, tracked }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.5.1",
3
+ "version": "4.7.0",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,111 @@
1
+ // candidate-ci.mjs - the candidate CI of `starci app release push` (OWNER RULING candidate-ci, 2026-10-18: "never again have main receive a release whose CI is
2
+ // red, with the owner confirming afterwards"). The push sends the exact release sha to refs/heads/release/candidate-<version> first (the managed ci.yml triggers on
3
+ // release/candidate-*), waits for the ci.yml run of THAT sha, and moves the default branch and the tag only when it is green:
4
+ // green the newest non-cancelled run of the sha concluded success
5
+ // red RELEASE_CANDIDATE_CI_RED with the run URL and the failing jobs and steps; nothing reaches the default branch. The candidate ref stays for diagnosis
6
+ // timeout RELEASE_CANDIDATE_CI_TIMEOUT: no run started within the grace, or it did not end within the wait
7
+ // unknown RELEASE_CI_UNKNOWN: GitHub could not be read to the end; a release never proceeds blind
8
+ // Emergency path (documented, owner-only): the owner's `starci release ci-override --sha <sha>` record for this exact sha
9
+ // (<git common dir>/starci-release/<sha>.ci-override.json, schema starci/ci-override@1) lets a red candidate through, and the push says so.
10
+ // Every process goes through ctx.spawn (git, gh); the wait through ctx.sleep and ctx.now.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ const CANDIDATE_PREFIX = 'refs/heads/release/candidate-';
15
+ /** The wait of the candidate run: poll interval, the grace for a run to appear, the wait for it to end (an app's ci.yml jobs time out within 45 minutes). */
16
+ const CANDIDATE_WAIT = Object.freeze({ pollSeconds: 30, graceMinutes: 10, waitMinutes: 90 });
17
+ const FIELDS = 'databaseId,headSha,status,conclusion,url,event,createdAt';
18
+ const SHA = /^[a-f0-9]{40}$/u;
19
+ const PASSING = new Set(['success', 'neutral', 'skipped']);
20
+
21
+ /** The candidate ref of the release tag `tag` with the app's tag prefix: refs/heads/release/candidate-<version>. */
22
+ export const candidateRef = (tag, prefix = 'v') => `${CANDIDATE_PREFIX}${String(tag).startsWith(prefix) ? String(tag).slice(prefix.length) : String(tag)}`;
23
+
24
+ function run(ctx, command, args) {
25
+ try { return ctx.spawn(command, args, { cwd: ctx.cwd }); } catch { return { status: null }; }
26
+ }
27
+
28
+ function jsonOf(result) {
29
+ if (!result || result.error || result.status !== 0) return null;
30
+ try { return JSON.parse(result.stdout); } catch { return null; }
31
+ }
32
+
33
+ /** pending (no run) | running | green | red over the runs of the sha; a cancelled run is no verdict. Pure. */
34
+ export function candidateVerdict(rows) {
35
+ const runs = (rows ?? []).filter((row) => row && row.conclusion !== 'cancelled');
36
+ runs.sort((a, b) => Date.parse(b.createdAt ?? 0) - Date.parse(a.createdAt ?? 0) || Number(b.databaseId) - Number(a.databaseId));
37
+ const newest = runs[0];
38
+ if (!newest) return { state: 'pending', run: null };
39
+ if (newest.status !== 'completed') return { state: 'running', run: newest };
40
+ return { state: newest.conclusion === 'success' ? 'green' : 'red', run: newest };
41
+ }
42
+
43
+ /** One read of the ci.yml runs of `sha`: {ok, state, run} or {ok: false}. */
44
+ function readCandidateRun(ctx, sha) {
45
+ const rows = jsonOf(run(ctx, 'gh', ['run', 'list', '--workflow', 'ci.yml', '--commit', sha, '--limit', '20', '--json', FIELDS]));
46
+ if (!Array.isArray(rows)) return { ok: false };
47
+ return { ok: true, ...candidateVerdict(rows.filter((row) => !row.headSha || row.headSha === sha)) };
48
+ }
49
+
50
+ /** The failing jobs and steps of a run: ['job (conclusion): step, step']; [] when unreadable. */
51
+ export function failingJobs(ctx, runRow) {
52
+ if (!/^\d+$/u.test(String(runRow?.databaseId ?? ''))) return [];
53
+ const reply = jsonOf(run(ctx, 'gh', ['run', 'view', String(runRow.databaseId), '--json', 'jobs']));
54
+ return (reply?.jobs ?? []).filter((job) => job.conclusion && !PASSING.has(job.conclusion)).map((job) => {
55
+ const steps = (job.steps ?? []).filter((step) => step.conclusion && !PASSING.has(step.conclusion)).map((step) => step.name);
56
+ return `${job.name} (${job.conclusion})${steps.length ? `: ${steps.join(', ')}` : ''}`;
57
+ });
58
+ }
59
+
60
+ /** Only a complete starci/ci-override@1 record for this exact commit can excuse a red candidate. */
61
+ export function overrideOf(ctx, sha) {
62
+ if (!SHA.test(String(sha))) return null;
63
+ const common = run(ctx, 'git', ['rev-parse', '--git-common-dir']);
64
+ if (common.error || common.status !== 0 || !String(common.stdout ?? '').trim()) return null;
65
+ const file = path.join(path.resolve(ctx.cwd, String(common.stdout).trim()), 'starci-release', `${sha}.ci-override.json`);
66
+ try {
67
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
68
+ if (record.schema !== 'starci/ci-override@1' || record.sha !== sha || typeof record.reason !== 'string' || !record.reason.trim() || typeof record.by !== 'string' || !record.by.trim() || typeof record.at !== 'string' || !Number.isFinite(Date.parse(record.at))) return null;
69
+ return record;
70
+ } catch { return null; }
71
+ }
72
+
73
+ /** Poll the candidate run of `sha` until green or red, or the grace (no run) or the wait passes: {state: green|red|timeout|unstarted|unknown, run}. */
74
+ export async function waitForCandidate(ctx, sha, wait = CANDIDATE_WAIT) {
75
+ const started = ctx.now();
76
+ let readable = null;
77
+ for (;;) {
78
+ const seen = readCandidateRun(ctx, sha);
79
+ if (seen.ok) readable = seen;
80
+ if (seen.ok && (seen.state === 'green' || seen.state === 'red')) return seen;
81
+ const elapsed = ctx.now() - started;
82
+ if (seen.ok && seen.state === 'pending' && elapsed >= wait.graceMinutes * 60_000) return { state: 'unstarted', run: null };
83
+ if (elapsed >= wait.waitMinutes * 60_000) return readable ? { state: 'timeout', run: readable.run } : { state: 'unknown', run: null };
84
+ await ctx.sleep(wait.pollSeconds * 1000);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The candidate step of the push: push HEAD to the candidate ref (through the pre-push hook), wait for its CI. {ok, lines, data: {ref, state, run, failing, override}}.
90
+ * ok only on green, or on red under the owner's override record for this exact sha.
91
+ */
92
+ export async function candidateStep(ctx, { head, tag, prefix, remote, wait = CANDIDATE_WAIT }) {
93
+ const ref = candidateRef(tag, prefix);
94
+ const lines = [];
95
+ const pushed = run(ctx, 'git', ['push', remote, `+${head}:${ref}`]);
96
+ if (pushed.status !== 0) return { ok: false, lines: [`candidate push of ${head.slice(0, 10)} to ${ref} failed: ${String(pushed.stderr ?? '').trim().split('\n')[0]}`], data: { ref, state: 'push-failed' } };
97
+ lines.push(`candidate pushed ${head.slice(0, 10)} to ${remote} ${ref}; waiting for its ci.yml run`);
98
+ const seen = await waitForCandidate(ctx, head, wait);
99
+ const url = seen.run?.url ? ` ${seen.run.url}` : '';
100
+ if (seen.state === 'green') return { ok: true, lines: [...lines, `candidate CI green${url}`], data: { ref, state: 'green', run: seen.run } };
101
+ if (seen.state === 'red') {
102
+ const failing = failingJobs(ctx, seen.run);
103
+ const record = overrideOf(ctx, head);
104
+ const detail = failing.length ? failing.join('; ') : 'the failing jobs could not be read';
105
+ if (record) return { ok: true, lines: [...lines, `candidate CI RED${url}; failing: ${detail}`, `RELEASE_CANDIDATE_CI_RED overridden by the owner (${record.by}): ${record.reason}`], data: { ref, state: 'red', run: seen.run, failing, override: { by: record.by, reason: record.reason, at: record.at } } };
106
+ return { ok: false, lines: [...lines, `RELEASE_CANDIDATE_CI_RED: the ci.yml run of ${head.slice(0, 10)} on ${ref} is red${url}; failing: ${detail}. Nothing reached the default branch; the candidate ref stays for diagnosis`], data: { ref, state: 'red', run: seen.run, failing } };
107
+ }
108
+ if (seen.state === 'unstarted') return { ok: false, lines: [...lines, `RELEASE_CANDIDATE_CI_TIMEOUT: no ci.yml run started for ${head.slice(0, 10)} on ${ref} within ${wait.graceMinutes} minutes (does .github/workflows/ci.yml trigger on release/candidate-*? run starci app sync)`], data: { ref, state: 'unstarted' } };
109
+ if (seen.state === 'timeout') return { ok: false, lines: [...lines, `RELEASE_CANDIDATE_CI_TIMEOUT: the ci.yml run of ${head.slice(0, 10)} did not end within ${wait.waitMinutes} minutes${url}`], data: { ref, state: 'timeout', run: seen.run } };
110
+ return { ok: false, lines: [...lines, `RELEASE_CI_UNKNOWN: GitHub could not be read for the candidate ${head.slice(0, 10)} on ${ref}; a release never proceeds blind`], data: { ref, state: 'unknown' } };
111
+ }
package/release/gate.mjs CHANGED
@@ -10,7 +10,6 @@ import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
10
10
  import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
11
11
  import { firstLine, lines, tool } from '../stack/context.mjs';
12
12
  import { failure, refusal } from '../stack/errors.mjs';
13
- import { latestDefaultBranchCi, requireGreenCi } from './ci-red.mjs';
14
13
 
15
14
  export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
16
15
  const SONAR_UNREACHABLE = 'SONAR_UNREACHABLE';
@@ -346,14 +345,14 @@ function summaryOf(result) {
346
345
  */
347
346
  export async function releaseGate(ctx, config, options = {}) {
348
347
  if (options.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
349
- const ci = latestDefaultBranchCi({ spawn: ctx.spawn, cwd: ctx.cwd, defaultBranch: config.defaultBranch });
350
- const ciLines = requireGreenCi(ctx, ci);
348
+ // The CI verdict of a release is the candidate run of its own sha, judged by `starci app release push` (candidate-ci.mjs, OWNER RULING candidate-ci 2026-10-18):
349
+ // the gate no longer refuses over the latest default-branch run.
351
350
  const facts = inspect(ctx, config, options);
352
351
  const common = releaseDir(ctx);
353
352
  const recordPath = path.join(common, `${facts.head}.l4.json`);
354
353
  const dir = path.join(common, facts.head.slice(0, 12));
355
- const plan = [...ciLines, ...facts.out, ...planLines(config, facts, dir, recordPath)];
356
- const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name), ci: { state: ci.state, notices: ciLines } };
354
+ const plan = [...facts.out, ...planLines(config, facts, dir, recordPath)];
355
+ const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name) };
357
356
  if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
358
357
  if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
359
358
  const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
package/release/push.mjs CHANGED
@@ -1,11 +1,14 @@
1
1
  // push.mjs - `starci app release push`: sends the default branch and the release tag to the remote in ONE atomic push through the repository's own
2
2
  // pre-push hook (never --no-verify). It requires what the hook requires: an annotated release tag on HEAD and the L4 record of the gate for that
3
3
  // head and tag. `git push --dry-run --atomic` always runs first (the hook's verdict is the proof) and any failure refuses the push.
4
+ // With --execute the exact sha first goes to its candidate ref refs/heads/release/candidate-<version> and the push waits for the ci.yml run of that sha
5
+ // (candidate-ci.mjs, OWNER RULING candidate-ci 2026-10-18): only a green candidate (or the owner's sha-bound override record) moves the branch and the tag.
4
6
  import fs from 'node:fs';
5
7
  import path from 'node:path';
6
8
  import { firstLine } from '../stack/context.mjs';
7
9
  import { failure, refusal } from '../stack/errors.mjs';
8
10
  import { annotatedOnHead, git, refName, releaseDir } from './gate.mjs';
11
+ import { candidateRef, candidateStep } from './candidate-ci.mjs';
9
12
 
10
13
  const VERDICT = /RIGHTS_PUSH_NOT_RELEASE|pre-push|failed to push|Everything up|->/u;
11
14
 
@@ -95,7 +98,9 @@ export async function releasePush(ctx, config, options = {}) {
95
98
  const red = [];
96
99
  const refs = [...branchRefs(ctx, { head, remote, branch }, red).refs, ...tagRefs(ctx, { remote, tag: gate.tag }, red)];
97
100
  data.refs = refs;
98
- out.push(`plan git push --atomic ${remote} ${refs.join(' ') || '(nothing)'}`);
101
+ const candidate = refs.length > 0 ? candidateRef(gate.tag, config.tagPrefix) : null;
102
+ if (candidate) out.push(`plan git push ${remote} +${head.slice(0, 10)}:${candidate}, wait for its ci.yml run to be green, then git push --atomic ${remote} ${refs.join(' ')}`);
103
+ else out.push(`plan git push --atomic ${remote} (nothing)`);
99
104
  if (refs.length === 0 && red.length === 0) return { ok: true, lines: [...out, `already pushed: ${remote}/${branch} holds HEAD and ${gate.tag} is on ${remote}. Nothing to do.`], data };
100
105
  if (refs.length > 0) {
101
106
  const hook = probe(ctx, remote, refs, red);
@@ -104,6 +109,10 @@ export async function releasePush(ctx, config, options = {}) {
104
109
  }
105
110
  if (red.length > 0) return refuse(out, red, data, options.execute === true ? 'nothing pushed.' : 'dry run: nothing pushed.');
106
111
  if (options.execute !== true) return { ok: true, lines: [...out, 'dry run: nothing pushed. ready; re-run with --execute.'], data };
112
+ const judged = await candidateStep(ctx, { head, tag: gate.tag, prefix: config.tagPrefix, remote, wait: options.candidateWait });
113
+ data.candidate = judged.data;
114
+ out.push(...judged.lines);
115
+ if (!judged.ok) return { ok: false, lines: [...out, `nothing pushed to ${branch} or ${gate.tag}.`], data };
107
116
  const run = git(ctx, ['push', '--atomic', remote, ...refs], { allowFail: true });
108
117
  if (run.code !== 0) return { ok: false, lines: [...out, `push failed: ${firstLine(run.stderr)}`], data };
109
118
  return { ok: true, lines: [...out, `pushed to ${remote}: ${refs.join(' ')}`], data: { ...data, pushed: true } };
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.4.3
32
+ version: 3.4.5
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.4.3
39
+ version: 8.4.5
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.5.1
79
+ version: 1.7.0
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.5.1
86
+ version: 4.7.0
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -466,6 +466,11 @@ pins:
466
466
  group: framework
467
467
  side: be
468
468
  why: 'the GraphQL transport of every back end (be.transport.graphql) that the front end calls; the ecommerce-app resolution on 2026-10-13. Stays on 13.x for alpha.18: the npm-audit advisory fixed by 14.0.3 (semver major) is a documented accepted exception, upgrade deferred to alpha.19 (owner ruling graphql-13-advisory-accepted-until-a19, 2026-10-16).'
469
+ '@graphql-tools/utils':
470
+ version: 12.1.0
471
+ group: framework
472
+ side: be
473
+ why: 'an npm override of every app root (and of the examples that serve GraphQL), never a declared dependency: @nestjs/graphql 13.x pulls @graphql-tools/utils <=12.0.0, which npm audit --audit-level=high reports (GHSA-7mx3-vvmw-hjmv, prototype pollution in mergeDeep). 12.1.0 is a fixed release on the same major and the lowest one every declared range accepts (@graphql-tools/schema and /merge, used at runtime, require ^12.1.0; NIVO v0.2.1 6a1d8e80 overrides 12.0.4, below that range), so the advisory closes without the breaking @nestjs/graphql 14. DROP this pin and the override when @nestjs/graphql 14 is adopted (owner ruling graphql-tools-utils-override, 2026-10-18).'
469
474
  '@nestjs/apollo':
470
475
  version: 13.4.5
471
476
  group: framework